Skip to content

feat(java): saga compensation for workflows#335

Merged
pratyush618 merged 7 commits into
masterfrom
feat/java-workflows-saga
Jun 30, 2026
Merged

feat(java): saga compensation for workflows#335
pratyush618 merged 7 commits into
masterfrom
feat/java-workflows-saga

Conversation

@pratyush618

@pratyush618 pratyush618 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Adds saga-style rollback to Java workflows. When a run with compensable steps
fails, completed steps are compensated in reverse-dependency order — each wave
of independent rollbacks runs in parallel, the next wave only after the previous
drains. The run ends compensated (or compensation_failed if a rollback
itself fails), and observers never see a transient failed first.

API

  • Step.compensate(Task<?>) / compensate(String) — register a rollback task.
    The compensation job runs with the forward step's result as its payload.
  • No new Workflow surface; compensation is driven entirely by the worker
    tracker (trackWorkflows()).

Mechanics

  • SagaOrchestrator (worker-side): on a failed run with compensable completed
    nodes, sets the run compensating, dispatches reverse-topo waves of
    compensation jobs (idempotency-keyed compensation:{run}:{node}, tagged
    compensation:true metadata so they never advance the forward run), then
    finalizes compensated / compensation_failed.
  • WorkflowTracker.finalizeIfTerminal checks for compensable failure BEFORE the
    core marks the run failed; compensation-job outcomes route to the saga via
    isCompensationJob.
  • New JNI fns (thin wrappers over existing taskito-workflows saga primitives):
    setWorkflowRunCompensating/Compensated/CompensationFailed,
    setWorkflowNodeCompensationJob/Compensated/CompensationFailed,
    setWorkflowRunCompletedWithFailures. No new core storage.

Tests

WorkflowSagaTest: a 3-step run whose last step fails compensates the two
completed steps (correct results as payloads, reverse order, run compensated);
a run with no compensators still fails normally.

Stacked on #334 (needs WorkflowException). Rebased onto master.

Summary by CodeRabbit

  • New Features
    • Added saga compensation support: steps can declare an optional compensate task, which is reflected in workflow plans.
    • Workflow runs and individual nodes now report compensation lifecycle states (compensating, compensated, compensation failed).
    • Added validation to prevent unsupported step types from declaring compensators, and disallow compensators in child workflows.
  • Bug Fixes
    • Updated failure finalization to automatically start saga compensation and ensure terminal states reflect compensation outcomes.
  • Tests
    • Added integration coverage for successful compensation ordering/results and for runs that fail normally when no compensators are configured.

A failed run with compensable steps rolls back: Step.compensate registers
a rollback task, and on failure the tracker compensates completed steps in
reverse-dependency waves (parallel within a wave). Adds saga JNI fns +
compensate metadata + a SagaOrchestrator; the run ends compensated (or
compensation_failed). The terminal state is set without a transient Failed.
@github-actions github-actions Bot added the rust label Jun 30, 2026
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 70bea30c-d548-49f5-808f-5554735f6dd8

📥 Commits

Reviewing files that changed from the base of the PR and between c310bfe and 71d0e1b.

📒 Files selected for processing (1)
  • sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java
📝 Walkthrough

Walkthrough

Adds saga compensation support for workflow steps. Steps can declare compensators, plan data now carries that field, failed top-level runs trigger reverse-order compensation waves, and JNI-backed storage records run and node compensation states. Integration tests cover compensated and non-compensated failure paths.

Changes

Saga Compensation Feature

Layer / File(s) Summary
Step and plan compensation field
sdks/java/src/main/java/org/byteveda/taskito/workflows/Step.java, sdks/java/src/main/java/org/byteveda/taskito/DefaultTaskito.java, sdks/java/src/main/java/org/byteveda/taskito/workflows/PlanNode.java, sdks/java/src/main/java/org/byteveda/taskito/workflows/RunPlan.java, crates/taskito-java/src/workflows/mod.rs
Step gains a compensate field and builder methods, DefaultTaskito serializes it and rejects child compensators, PlanNode and PlanNodeView expose the field, RunPlan adds lookup helpers, and Rust step metadata preserves the value.
JNI compensation state updates
sdks/java/src/main/java/org/byteveda/taskito/internal/NativeWorkflows.java, sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java, sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java, crates/taskito-java/src/workflows/mod.rs
Java and Rust add compensation lifecycle entry points for run-level and node-level state changes, including compensating, compensated, compensation failed, and compensation job tracking.
SagaOrchestrator implementation
sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java
New orchestration code selects compensable nodes, computes reverse-topology waves, enqueues compensation jobs with deterministic IDs, handles completions, and finalizes saga state.
WorkflowTracker integration
sdks/java/src/main/java/org/byteveda/taskito/workflows/WorkflowTracker.java
Tracker construction, outcome handling, terminal-run finalization, cleanup visibility, and terminal-node checks are updated to hand failed runs to saga compensation.
Workflow saga integration tests
sdks/java/src/test/java/org/byteveda/taskito/WorkflowSagaTest.java
Two JUnit 5 tests cover compensated rollback after failure and normal failure behavior without compensators.

Sequence Diagram(s)

sequenceDiagram
  participant WorkflowTracker
  participant SagaOrchestrator
  participant QueueBackend

  WorkflowTracker->>SagaOrchestrator: startCompensation(runId, plan, statuses)
  SagaOrchestrator->>QueueBackend: setWorkflowRunCompensating(runId)
  SagaOrchestrator->>QueueBackend: enqueue compensation wave
  QueueBackend-->>WorkflowTracker: onOutcome(compensation job)
  WorkflowTracker->>SagaOrchestrator: onCompensationCompleted(...)
  SagaOrchestrator->>QueueBackend: setWorkflowNodeCompensated / setWorkflowNodeCompensationFailed
  alt more waves remain
    SagaOrchestrator->>QueueBackend: enqueue next wave
  else saga ends
    SagaOrchestrator->>QueueBackend: setWorkflowRunCompensated / setWorkflowRunCompensationFailed
    SagaOrchestrator->>WorkflowTracker: forget(runId)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ByteVeda/taskito#334: Touches the same DefaultTaskito.encodeChild(...) validation path, with nearby workflow-step handling changes.

Suggested labels

workflows

🐇 The rabbit saw a failing run,
and turned it back to heal as one.
Waves rolled out in reverse-deep order,
with JNI notes along the border.
Compensate, hop, and make things right,
then every saga lands just so at night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding saga compensation support for Java workflows.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/java-workflows-saga

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/taskito-java/src/workflows/mod.rs (1)

524-585: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve the effective queue in getWorkflowPlan().

Saga rollback now re-enqueues compensators from this plan JSON, but this view still returns queue = null when a step inherited the run default. The Java side then falls back to PlanNode.queueOrDefault(), so the undo job can land on literal "default" instead of the forward lane and never be picked up by the worker that handled the original step. Based on learnings, getWorkflowPlan may omit the run default queue, which later breaks deferred job creation in WorkflowTracker; the long-term fix is a native serialization change so getWorkflowPlan includes the run default queue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-java/src/workflows/mod.rs` around lines 524 - 585,
`Java_org_byteveda_taskito_internal_NativeWorkflows_getWorkflowPlan` is
serializing `PlanNodeView.queue` as null when a step inherits the run default,
which later causes rollback/retry path logic to fall back to `"default"` instead
of the original lane. Update the native plan serialization in `getWorkflowPlan`
so it emits the effective queue for each node, using the step metadata when
present and otherwise the workflow run’s default queue, and keep `PlanNodeView`
as the place where this value is populated for the JSON returned to Java.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdks/java/src/main/java/org/byteveda/taskito/DefaultTaskito.java`:
- Around line 478-480: The child-workflow path is now serializing step-level
compensators even though nested runs cannot be rolled back by
WorkflowTracker.finalizeIfTerminal(), so child failures may leave uncompensated
side effects. Update encodeChild()/stepSpec() in DefaultTaskito to either reject
or omit compensate for child workflows until child-run compensation is
supported, and keep the top-level saga behavior unchanged.

In
`@sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java`:
- Around line 69-72: Make saga startup in SagaOrchestrator.startCompensation
idempotent and failure-safe by preventing duplicate initialization for the same
runId: use runs.putIfAbsent or a per-run lock instead of overwriting the
existing SagaRun. Ensure dispatchNextWave is only invoked once the compensating
run is safely recorded, and if dispatching the first wave fails, transition the
backend state from COMPENSATING to compensation_failed so the run is not left
half-started.
- Around line 41-43: The compensation routing in SagaOrchestrator is relying too
much on the in-memory compensationJobs map, which can miss fast completions and
is lost on restart. Update isCompensationJob and the enqueue/registration flow
so compensation jobs are identified from persisted compensation metadata or
durable compensation job IDs, not only by compensationJobs.containsKey(jobId).
Ensure the backend.enqueue path cannot expose a job before routing information
is available, and restore the same routing after tracker restart.
- Around line 178-180: Update SagaOrchestrator.isCompensable so that
compensation only applies to steps that actually executed in the current run;
remove CACHE_HIT from the compensable statuses and keep only
NodeStatus.COMPLETED (or otherwise gate compensation on execution state). Use
the isCompensable helper and the compensation flow in SagaOrchestrator to ensure
cached results do not trigger compensators by default.
- Around line 53-55: The compensation path in SagaOrchestrator is silently
falling back to an empty payload when backend.getResult(snap.jobId) returns
nothing, which violates the rollback contract. Update the forward-result
retrieval logic in the compensation flow to fail fast if the result is missing
instead of using new byte[0], and ensure the rollback invocation only proceeds
with a real forward payload obtained from backend.getResult.
- Around line 88-100: Serialize per-run wave advancement in SagaOrchestrator:
the compensation completion path can let multiple outcomes see run.inflight
empty at the same time and both advance the saga. Update the logic around
run.inflight.remove(node) and the in-flight empty check so only one thread can
transition a run from one wave to the next, and guard the
finalizeSaga/run.dispatchNextWave decision with synchronization on the per-run
state (or another single-run lock) to prevent duplicate dispatches.

In `@sdks/java/src/main/java/org/byteveda/taskito/workflows/Step.java`:
- Around line 197-211: The compensation API on Step.Builder currently allows
registering rollback tasks for step kinds that never produce a forward result,
which later causes SagaOrchestrator.startCompensation() to enqueue an undo task
with an empty payload. Update Step.Builder.build() (or the validation path
around compensate(String)/compensate(Task<?>)) to reject compensation for node
types that cannot emit a forward job result, using the existing Step/Builder and
node-kind checks to fail fast before the API can be used incorrectly.

In `@sdks/java/src/main/java/org/byteveda/taskito/workflows/WorkflowTracker.java`:
- Around line 458-466: The compensation gate in WorkflowTracker should not
trigger on any failed node alone, because runs with on_failure or always
recovery may still finalize as completed_with_failures. Update the saga start
check to use the same terminal-state classification as
backend.finalizeRunIfTerminal(...) or a shared non-mutating classifier before
calling saga.startCompensation, and keep the childToParent/runId top-level check
intact. Ensure the decision is based on whether the run would actually finalize
as failed, not just whether NodeStatus.FAILED appears in statuses.

In `@sdks/java/src/test/java/org/byteveda/taskito/WorkflowSagaTest.java`:
- Around line 40-63: Update WorkflowSagaTest so the compensation assertions
verify both execution order and single invocation, not just unique payloads in a
Set. Replace the current undone collection in the test with an order-preserving
structure plus per-compensator call tracking, then assert that UNDO_B runs
before UNDO_A and that each compensator is invoked exactly once after
trackWorkflows() completes and run.await(...) returns.

---

Outside diff comments:
In `@crates/taskito-java/src/workflows/mod.rs`:
- Around line 524-585:
`Java_org_byteveda_taskito_internal_NativeWorkflows_getWorkflowPlan` is
serializing `PlanNodeView.queue` as null when a step inherits the run default,
which later causes rollback/retry path logic to fall back to `"default"` instead
of the original lane. Update the native plan serialization in `getWorkflowPlan`
so it emits the effective queue for each node, using the step metadata when
present and otherwise the workflow run’s default queue, and keep `PlanNodeView`
as the place where this value is populated for the JSON returned to Java.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a558422e-644e-4c07-b708-183b2fe05266

📥 Commits

Reviewing files that changed from the base of the PR and between eabda68 and 97b290c.

📒 Files selected for processing (11)
  • crates/taskito-java/src/workflows/mod.rs
  • sdks/java/src/main/java/org/byteveda/taskito/DefaultTaskito.java
  • sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java
  • sdks/java/src/main/java/org/byteveda/taskito/internal/NativeWorkflows.java
  • sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java
  • sdks/java/src/main/java/org/byteveda/taskito/workflows/PlanNode.java
  • sdks/java/src/main/java/org/byteveda/taskito/workflows/RunPlan.java
  • sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java
  • sdks/java/src/main/java/org/byteveda/taskito/workflows/Step.java
  • sdks/java/src/main/java/org/byteveda/taskito/workflows/WorkflowTracker.java
  • sdks/java/src/test/java/org/byteveda/taskito/WorkflowSagaTest.java

Comment thread sdks/java/src/main/java/org/byteveda/taskito/DefaultTaskito.java
Comment thread sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java Outdated
Comment thread sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java Outdated
Comment thread sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java Outdated
Comment thread sdks/java/src/main/java/org/byteveda/taskito/workflows/Step.java
Comment thread sdks/java/src/test/java/org/byteveda/taskito/WorkflowSagaTest.java Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java`:
- Around line 114-121: `SagaOrchestrator.onCompensationCompleted` advances to
`dispatchNextWave(...)` without the same safety net used in
`startCompensation()`, so an enqueue/dispatch exception can leave the run stuck
in COMPENSATING. Wrap the `dispatchNextWave(runId, run)` call in a try/catch
that handles the `WorkflowException` path, logs the failure, and calls
`finalizeSaga(runId, run)` just like the start-path guard; use the existing
`finalizeSaga`, `dispatchNextWave`, and `onCompensationCompleted` flow to keep
the run from remaining in `runs`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7cb0bf8-c579-4b91-8929-3e497dc9480b

📥 Commits

Reviewing files that changed from the base of the PR and between 97b290c and c310bfe.

📒 Files selected for processing (4)
  • sdks/java/src/main/java/org/byteveda/taskito/DefaultTaskito.java
  • sdks/java/src/main/java/org/byteveda/taskito/workflows/SagaOrchestrator.java
  • sdks/java/src/main/java/org/byteveda/taskito/workflows/Step.java
  • sdks/java/src/test/java/org/byteveda/taskito/WorkflowSagaTest.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • sdks/java/src/test/java/org/byteveda/taskito/WorkflowSagaTest.java

@pratyush618
pratyush618 merged commit 78591d9 into master Jun 30, 2026
35 checks passed
@pratyush618
pratyush618 deleted the feat/java-workflows-saga branch June 30, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant